# NewsBlur Tutorial

Written and tested by Emrys Mayell. [emrysmayell.com](https://emrysmayell.com/homelab/newsblur-tutorial/)

## Table of Contents

- [Introduction](#introduction)
- [Tools](#tools)
- [Architecture](#architecture)
- [Step 1: Installing NewsBlur in Docker](#step-1-installing-newsblur-in-docker)
- [Step 2: Configuring NewsBlur](#step-2-configuring-newsblur)
- [Step 3: Networking and Reverse Proxy](#step-3-networking-and-reverse-proxy)
- [Step 4: SSL Certificates](#step-4-ssl-certificates)
- [Step 5: Automated Certificate Renewal](#step-5-automated-certificate-renewal)
- [Step 6: Email Notifications](#step-6-email-notifications)
- [Troubleshooting](#troubleshooting)
- [Backups and Updates](#backups-and-updates)
- [Substitutions](#substitutions)
- [Conclusion](#conclusion)

## Introduction

This is a guide for setting up NewsBlur on a Windows PC. I used Tailscale, my own subdomain, and a reverse proxy to access it from anywhere. NewsBlur documentation isn't great and using Windows as your server OS means you'll typically have less documentation than with a Linux config, so I've written this guide to be as comprehensive as possible. If you're here, I assuming you're familiar with basic networking principles and you're moderately comfortable with the command line, but I cover some of the more basic stuff just in case.

I have a specific setup that may be a little different than yours, but I've included plenty of information about how to substitute alternative services, and the overall structure is going to be pretty similar no matter what tools you use. See [Substitutions](#substitutions) for more information.

Without further ado, let's get started!

## Tools

### What you should have going in

- **A Windows machine** with at least 8 GB RAM (16 GB more comfortable) and ~30 GB free disk space.
- **Tailscale** installed and your tailnet set up. The server should be visible at a `100.x.x.x` IP from your other devices. If you don't know how to do this part, check out [Tailscale's documentation](https://tailscale.com/docs), which is extremely comprehensive.
- **A domain name**. If you don't have a domain, you can just use the one that Tailscale gives you when you add a machine to your tailnet. In this guide, `yourdomain.com` is always the placeholder for whatever you choose.
- **Cloudflare DNS** (free tier). If your DNS is elsewhere, that works too.
- **An email provider** if you want email notifications from NewsBlur. I use Proton Mail (paid plan) so I can get emails from an address at my domain, but an email account from Gmail or most other providers work just fine.

### Software you'll install during this process

- **WSL2** (Windows Subsystem for Linux) with Ubuntu
- **Docker Desktop** with WSL2 integration
- **NGINX** (native Windows binary)
- **NGINX Proxy Manager** (a Docker container, separate from the native NGINX)

## Architecture

This is the flow of traffic in this implementation of NewsBlur.

```
Browser (remote device on tailnet)
    │  https://news.emrysmayell.com
    ▼
DNS resolution
    │  Cloudflare DNS
    ▼
Tailscale authentication
    │  Tailscale IP
    ▼
Server's tailnet interface
    │  http://localhost:443
    ▼
Native Windows NGINX (terminates TLS with Let's Encrypt cert)
    │  http://localhost:44343
    ▼
NewsBlur HAProxy (in Docker container)
    │
    ▼
NewsBlur internal NGINX → Gunicorn → Django → The rest of NewsBlur
```

## Step 1: Installing NewsBlur in Docker

### 1.1 Check for conflicting services

Before you start, check for other services listening on port 443. Even if you haven't used this machine for self-hosting before, something like IIS may be running in the background and conflict with NGINX later. Run in PowerShell:

```powershell
netstat -ano | findstr ":443"
```

Anything in that output other than Docker's backend (identified by a Process ID that you can look up in Task Manager → Details) will conflict with your setup and needs to be stopped or removed before continuing. This could be IIS, NGINX or another reverse proxy, or a service you forgot about. If you find something, make sure the service doesn't autostart next time you reboot your machine (Task Manager → Startup apps).

### 1.2 Set up WSL2

If WSL2 isn't already installed, open PowerShell as administrator and run:

```powershell
wsl --install
```

This installs WSL2 with Ubuntu by default. You can install a different distro if you'd like, but there's no reason to for this project. Reboot when prompted, then complete the initial Ubuntu setup (create a user account or accept root).

After reboot, verify that the installation was successful:

```powershell
wsl -l -v
```

The output should show `Ubuntu` and `VERSION 2`. If it shows VERSION 1, upgrade it:

```powershell
wsl --set-version Ubuntu 2
```

I recommend making a shortcut to the WSL terminal at this point. Search for an application named Ubuntu (not WSL) and you can pin that to the Start menu or to your Taskbar like any other app. Opening jumps you straight into the terminal.

### 1.3 Set up Docker Desktop

Download Docker Desktop from [docker.com](https://docs.docker.com/desktop/setup/install/windows-install/) and install. After installation:

1. Open Docker Desktop → **Settings** (gear icon)
2. Go to **General** → make sure "Use the WSL 2 based engine" is checked
3. Go to **Resources** → **WSL Integration** → toggle on your Ubuntu distro
4. Click **Apply & Restart**

Verify the integration worked. In WSL terminal:

```bash
docker --version
docker ps
```

The first line shows the Docker version, the second line shows all existing Docker containers. If you get an error saying command `docker` is not found, the WSL integration didn't activate and you should check Docker Desktop's WSL Integration setting.

### 1.4 Clone NewsBlur

Clone the NewsBlur repository into your WSL2 home directory. From WSL:

```bash
cd ~
git clone https://github.com/samuelclay/NewsBlur.git
cd NewsBlur
```

Line 1 takes you to the WSL root directory, line 2 clones the NewsBlur repo to the root, and line 3 puts you in the newly created NewsBlur folder.

> **Note**
> It's important that your NewsBlur files are in WSL and not in a Windows folder, because the cross-filesystem boundary between WSL2 and Windows' NTFS causes permission errors with PostgreSQL and MongoDB. If you try to host the files in NTFS, you'll get the Portgres error `operation not permitted` in the next few steps.

### 1.5 Update ports in Docker

Change the NewsBlur ports away from the default ports for HTTP and HTTPS (80 and 443, respectively) because even if this is the first service you're running on this machine, it's best to avoid a conflict down the line. Most home setups already have other services on these ports (for example, native NGINX uses 443, which we'll get to later). NewsBlur's HAProxy publishes both by default and will fail to start with a port conflict.

To check which ports you can use, open your Terminal as an Administrator and run:

```bash
netstat -ab
```

This will show you all of the ports that are in use. Use anything that isn't listed, between 1024 through 49151. Here's some [non-essential reading](https://en.wikipedia.org/wiki/Port_(computer_networking)#Common_port_numbers) if you're interested in why that specific range is important. In my setup, I added "43" to the end of the default ports and had no issues, so I'll be using that example throughout the guide.

Open `docker-compose.yml` in `/root/NewsBlur/` and find the `haproxy` service. Change the ports section:

from:

```yaml
ports:
    - 80:80
    - 443:443
    - 1936:1936
```

to:

```yaml
ports:
    - 8043:80
    - 44343:443
    - 1936:1936
```

The host-side ports (left of the colon) can be anything unused; the container-side ports (right) must stay 80/443. Port 1936 is HAProxy's service monitoring page and can stay the same. Also update the `NGINX` service's port if it conflicts with NPM (which uses 81 by default):

```yaml
ports:
    - 8143:81
```

If you're running x86_64 Windows, you also have to remove the ARM-specific JVM flags from Elasticsearch. This is a NewsBlur-specific issue I ran into that took me a while to figure out, because the project assumes it's running on Apple Silicon and adds `-XX:UseSVE=0` to Elasticsearch's JVM options. This flag exists only on ARM64, so the JVM refuses to start on x86_64 Windows and Elasticsearch crashes in a loop. If you don't do this, you'll notice the Elasticsearch service in Docker starts and stops over and over.

If you're not sure, check whether you're running an ARM system in Powershell:

```powershell
(Get-ComputerInfo).CsSystemType -like "*ARM*"
```

If it returns `true`, skip this step. If it returns `false`, it's not an ARM machine and you need this step.

In `docker-compose.yml`, find the `newsblur_db_elasticsearch` service, and make a change:

from:

```yaml
- "ES_JAVA_OPTS=-Xms384m -Xmx384m -XX:UseSVE=0"
- "CLI_JAVA_OPTS=-XX:UseSVE=0"
```

to:

```yaml
- "ES_JAVA_OPTS=-Xms384m -Xmx384m"
```

### 1.6 Ensure WSL has enough memory

Elasticsearch will OOM-crash if WSL2's VM is undersized. Edit (or create) `C:\Users\<your-username>\.wslconfig` with:

```ini
[wsl2]
memory=8GB
processors=4
```

In PowerShell, run:

```bash
wsl --shutdown
```

Wait at least 10 seconds, then reopen WSL. Docker Desktop's WSL backend will use the new limits on next startup.

### 1.7 Raise WSL's virtual machine memory allocation

Elasticsearch also requires `vm.max_map_count >= 262144` in the kernel, because WSL2's default memory allocation is too low. To raise it, run this from WSL:

```bash
sudo sysctl -w vm.max_map_count=262144
```

To make it persistent across reboots, you'll need to add a line to WSL's `sysctl.conf` file. Navigate to`\\wsl$\Ubuntu\etc\sysctl.conf`, and update the `vm.max_map_count` line as follows:

```ini
vm.max_map_count=262144
```

> **Note**
> For those not familiar with WSL or Linux file paths, here are a couple tips. You can access the WSL file system in Windows File Explorer by entering `\\wsl$\Ubuntu` into the address bar. Paths like `\etc\sysctl.conf` are relative to this directory. Your NewsBlur instance will live at `\\wsl$\Ubuntu\root\NewsBlur`, and most file paths referencing a NewsBlur-specific file will be relative to that directory.

### 1.8 Run the install

From WSL, in your NewsBlur directory:

```bash
make
```

This is the maintainer's single-command install. It pulls and builds all images, generates self-signed certificates for HAProxy, brings up every container, and runs Django migrations. The first run takes 10-20 minutes depending on your network speed.

Once it returns to the prompt, verify everything is up:

```bash
docker ps
```

You should see all eight containers (haproxy, web, node, postgres, mongo, redis, elasticsearch, task_celery) in `Up` state. If anything is `Restarting` or `Exited`, see the [Troubleshooting](#troubleshooting) section.

Visit `https://localhost:44343` (or whatever port you remapped to 443) in a browser. You'll get a warning because the site doesn't have an SSL certificate yet, but bypass this by typing `thisisunsafe` (Chrome/Edge) or clicking through (Firefox) to confirm if NewsBlur is being served correctly. If it's working, you should see NewsBlur's signup screen.

## Step 2: Configuring NewsBlur

### 2.1 Create your account

Make a NewsBlur account on that signup screen. Use any username and email, they don't need to be real for a self-hosted instance. With NewsBlur's defaults, your first account is automatically activated and granted a 30-day premium trial.

### 2.2 Upgrade to permanent premium

The "30-day trial" notice you'll see on NewsBlur's home page is real (the premium features will disappear after 30 days), but since this is an self-hosted installation of an open-source program, it's very simple to give yourself permanent premium access. To do this, you'll drop into the Django shell. From WSL in your NewsBlur directory:

```bash
make shell
```

At the `>>>` prompt:

```python
u = User.objects.get(username='your_username')
u.profile.activate_archive()
```

It's worth noting that `activate_premium()`is also a tier, but `activate_archive()` is the highest, with all Premium features plus the option of permanent story retention (which you can change in NewsBlur settings on your own).

You can leave the Django shell with the command below, but stay there for a moment- it'll be useful in Step 2.3.

```python
exit()
```

If you create more accounts later, each one will need to be granted Archive account status individually. Or, you can auto-grant new accounts premium-archive status permanently. To do this, you'll create a file in newsblur_web called `local_settings.py`. This file contains your manual overrides of Docker's existing `docker_local_settings.py` file. Once you've created it, enter:

```python
AUTO_PREMIUM_NEW_USERS = True
AUTO_ENABLE_NEW_USERS = True
```

Save the file, but keep it open for a moment because you'll return to it in Step 2.5.

> **Note**
> When I first set up my instance, I didn't create `local_settings.py`, I just replaced the existing values in `docker_local_settings.py`. This will also work, but it's better to have a separate file so you can easily refer to the default settings (and return to them by simply deleting your overrides), and because if you update your NewsBlur instance, `docker_local_settings.py` will get overwritten but `local_settings.py` will not. In fact, `local_settings.py` is written in .gitignore even though the file doesn't exist out of the box, so you never have to worry about it being overwritten.

### 2.3 Update the Site object

This is where you'll start integrating the domain you're using to access NewsBlur into the system. Make sure you know the endpoint you're using to access it, whether it's your custom domain, one of its subdomains, or a Tailscale domain. For example, I use news.emrysmayell.com. Like I said earlier, anywhere you see "yourdomain.com", replace it with yours.

NewsBlur uses Django's Sites framework to build canonical URLs (for password reset emails, OAuth, etc.). The default is `example.com`, and if you don't change it, you'll run into a bug soon where NewsBlur will redirect your URL to example.com when you try to access the site. While you're still in `make shell`, enter:

```python
from django.contrib.sites.models import Site
site = Site.objects.get(pk=1)
site.domain = "yourdomain.com"
site.name = "yourdomain.com"
site.save()
```

### 2.4 Allow your subdomain

If you're using a subdomain, this step is critical (it took me over an hour to figure out). NewsBlur has a "blurblog" feature where each user gets a public blog at `<username>.newsblur.com`. Because of this, the app's URL routing tries to match any incoming subdomain against the user table, meaning if your URL is `news.emrysmayell.com` for example, NewsBlur looks for a user named "news," fails to find one, and it triggers an infinite loop in your browser (which will manifest as a page that says something along the lines of "this page failed to load - too many requests").

Open `apps/reader/views.py` and find the line that starts with `ALLOWED_SUBDOMAINS`:

```bash
grep -n "ALLOWED_SUBDOMAINS" apps/reader/views.py
```

Add your subdomain to the list:

```python
ALLOWED_SUBDOMAINS = ['www', 'beta', 'staging', 'discover', 'news', ...]
```

> **Note**
> This is an edit to the system files, so if you update your NewsBlur instance to a newer version, this change will get overwritten. I recommend keeping a runbook and documenting this quirk, and make sure you return to this step after an update.

### 2.5 Add your domain to local settings

In the `newsblur_web/local_settings.py` file you created earlier, enter these lines along with the `AUTO_PREMIUM_NEW_USERS` lines you added earlier:

```python
# newsblur_web/local_settings.py
    # Public URL
    NEWSBLUR_URL = "https://yourdomain.com"
    SESSION_COOKIE_DOMAIN = "yourdomain.com"
    # Trust upstream proxies' HTTPS header so Django doesn't try to redirect
    # traffic that is HTTP internally but served as HTTPS externally
    SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
```

Now you need to make sure that NEWSBLUR_URL variable is consistent everywhere. NewsBlur's `docker_local_settings.py` re-reads `NEWSBLUR_URL` from an environment variable named `NEWSBLUR_URL` and applies conditional logic based on it. If the environment variable isn't set, it defaults to `"https://localhost"` and then sets `SESSION_COOKIE_DOMAIN = "localhost"` regardless of what you put in your local_settings.py. This silently breaks session cookies for any non-localhost setup, which can result in your traffic being diverted to the wrong place later.

The workaround is to set `NEWSBLUR_URL` as an environment variable in `docker-compose.yml`. Find the `newsblur_web`, `task_celery`, and `newsblur_node` services and add that variable to their `environment:` blocks:

```yaml
newsblur_web:
    # ... existing config ...
    environment:
        - NEWSBLUR_URL=https://yourdomain.com

    task_celery:
    # ... existing ...
    environment:
        - NEWSBLUR_URL=https://yourdomain.com

    newsblur_node:
    # ... existing ...
    environment:
        - NEWSBLUR_URL=https://yourdomain.com
```

After editing the compose file, recreate the affected containers so they pick up the new env vars:

```bash
docker compose up -d newsblur_web task_celery newsblur_node
```

> **Note**
> `docker compose restart -d container-name` restarts a container, and it's a similar command that will come in handy later, but it's worth noting that `docker compose up -d container-name` is a slightly different command, which builds/rebuilds containers completely. The important differece in this case is that a `restart` won't read new `environment:` variables, only a rebuild with `up` will.

Verify the settings are loading:

```bash
make shell
```

```python
from django.conf import settings
print(settings.NEWSBLUR_URL)              # should return: https://yourdomain.com
print(settings.SESSION_COOKIE_DOMAIN)     # should return: yourdomain.com
print(settings.SECURE_PROXY_SSL_HEADER)   # should return: ('HTTP_X_FORWARDED_PROTO', 'https')
```

If any value is wrong, double check your `local_settings.py` file and the `environment:` variables you changed in Docker before continuing.

## Step 3: Networking and Reverse Proxy

### 3.1 Configure DNS in Cloudflare

On Tailscale's Machines page, get your server's IP address. Then, in the Cloudflare DNS records for your domain, add an A record:

| Type | Name | Content (IPv4) | Proxy status | TTL |
|------|------|---------------|--------------|-----|
| A | yourdomain (see Note 1) | `100.x.x.x` (your server's tailnet IP) | DNS only (gray cloud) | Auto |

This routes traffic from your domain of choice to your private Tailscale network, and next you'll configure where the traffic goes from there.

> **Note 1**
> If you're using a subdomain, enter just the subdomain here (in my case, `news`). If you're using a main domain, you probably have an existing DNS record that you can edit to replace the existing IP address with Tailscale's. The Name field will either have `@` or the domain name, such as `emrysmayell.com`. If it doesn't exist, create one.

> **Note 2**
> Cloudflare's edge proxy can't connect to private/tailnet IPs because their servers are public, which is why the Proxy Status has to be DNS-only. This is worth noting because most of your other DNS records will probably be proxied by default. Most likely, you won't even have the option to change this, but if you do, keep the button toggled off or you'll break the connection. DNS-only means all queries interface with your tailnet IP directly.

Get your tailnet IP from the server:

```bash
tailscale ip -4
```

Wait at least 30 seconds for DNS to propagate, then verify from another machine on your tailnet:

```bash
nslookup yourdomain.com
```

This should return your tailnet IP.

### 3.2 Install NGINX Proxy Manager

NGINX Proxy Manager (NPM) will manage SSL certificate issuance. For configurations on Linux, NPM could also route the traffic itself and it would be simpler to manage both in one place, but Docker on Windows can't expose its ports to virtual private network interfaces like Tailscale. Windows-native NGINX binds to port `0.0.0.0:443` meaning it can receive all types of traffic, and because of this, the best we can do is to use NPM on Docker for SSL certificate management, and Windows NGINX for traffic management.

To set up NPM, Create an `npm` directory and a file called `docker-compose.yml` inside of it.

Enter this into the new `docker-compose.yml`:

```yaml
services:
    npm:
        image: jc21/NGINX-proxy-manager:latest
        container_name: docker-npm-1
        restart: unless-stopped
        ports:
        - '8044:80'    # HTTP (not used externally; avoids port conflicts)
        - '81:81'      # NPM admin UI
        - '8045:443'   # HTTPS (not used externally either)
        volumes:
        - ./data:/data
        - ./letsencrypt:/etc/letsencrypt
```

The mapping `8044:80` and `8045:443` keeps NPM off the standard ports so native Windows NGINX can use 80/443 for actual public traffic without conflicts. NPM's port 81 is the admin page.

Build the container and start it:

```bash
docker compose up -d
```

Visit `http://localhost:81` in a browser. Default credentials are `admin@example.com` / `changeme` but you can change them on your first login.

### 3.3 Install native Windows NGINX

Download the Windows NGINX zip from [NGINX.org/en/download.html](https://nginx.org/en/download.html) (the mainline version). The link should read `nginx/Windows-[version.number]`. Extract to the Windows location `C:\Users\[user]\Documents\NGINX`. The folder should contain `NGINX.exe` and a `conf` subdirectory with `NGINX.conf` in it along with many other files.

Start it (from PowerShell, in that directory):

```powershell
cd C:\Users\[user]\Documents\NGINX
.\NGINX.exe
```

Verify it's running:

```powershell
netstat -ano | findstr ":443"
```

You should see entries with NGINX's PID. Again, if something else is using that port, stop/disable that service.

### 3.4 Configure NGINX for NewsBlur

Open `C:\Users\[user]\Documents\NGINX\conf\NGINX.conf`. Inside the existing `http { ... }` block, add a server block for NewsBlur. The full structure should look like:

```nginx
http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile      on;
    keepalive_timeout 65;

    server {
        listen 443 ssl;
        server_name yourdomain.com;

        # Cert paths set in Step 4 below; placeholders for now
        ssl_certificate     conf/newsblur-fullchain.pem;
        ssl_certificate_key conf/newsblur-privkey.pem;

        location / {
            # Forward to NewsBlur's HAProxy
            proxy_pass https://127.0.0.1:44343;
            proxy_ssl_verify off;
            proxy_ssl_server_name on;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
        }
    }
}
```

Notable lines:

- `listen 443 SSL` and `proxy_pass`: the former tells NGINX that traffic will come from port 443 (with an SSL certificate), and to send it to port 44343. If you configured a different port back in [Step 1.5](#1-5), replace 44343 with that instead.
- `proxy_ssl_verify off`: HAProxy serves a self-signed cert internally, and NGINX would reject it by default so this tells it to ignore that.
- `proxy_ssl_server_name on`: this sends the server name to HAProxy so it knows which hostname is being requested.
- `proxy_http_version 1.1` as well as the `Upgrade` and `Connection` lines: these enable WebSocket support, which lets NewsBlur update its pages in real time instead of on manual refresh.
- `X-Forwarded-Proto $scheme`: this tells everything downstream that the original connection was HTTPS, which Django needs to avoid an SSL-redirect loop.

> **Note**
> A majority of common self-hosted services (such as Jellyfin, Audiobookshelf, Kiwix, FreshRSS, and many more) serve HTTP to the client, as opposed to NewsBlur, which serves HTTPS. For those, you'd replace `https://` on the `proxy_pass` line with `http://` and drop the `proxy_ssl_*` lines, and otherwise the NGINX configuration would be the same.

## Step 4: SSL Certificates

### 4.1 Create a Cloudflare API token

1. Visit the API tokens page: [https://dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens)
2. Click **Create Token**
3. Use the **Edit zone DNS** template (one-click preset)
4. Under "Zone Resources," select **Include** → **Specific zone** → **yourdomain.com**. Leave all other fields as their defaults.
5. Continue to the summary, click **Create Token**
6. Copy the token immediately! Cloudflare shows it exactly once. You should copy it to a password manager to save it for later, because if you set up any other services on your machine and need an SSL certificate, you should use this same API token to create new SSLs for those as well.

The token has DNS-edit permission for your main domain and its subdomains, which means whether your NewsBlur instance is hooked up to a subdomain or the main one, this will work either way, and you can use it for other subdomains as well. Also, since it only covers DNS, it can't be used for anything else.

Verify the token works:

```bash
curl -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer YOUR_TOKEN"
```

If you get `"status": "active"` in the response, it's working.

### 4.2 Issue the certificate via NPM

1. Visit NPM's admin page at `http://localhost:81`
2. **SSL Certificates** in the top menu → **Add SSL Certificate** → **Let's Encrypt**
3. Domain Names: `yourdomain.com`
4. Email Address: your real email (Let's Encrypt sends expiry warnings here if auto-renew fails)
5. Toggle **Use a DNS Challenge** on
6. DNS Provider: **Cloudflare**
7. Credentials File Content: replace the placeholder text with `dns_cloudflare_api_token = YOUR_TOKEN`. Everything else in the placeholder text can be deleted for this process.
8. Then, agree to Let's Encrypt's Terms of Service and hit Save

The Save button will spin for a short time while NPM confirms the API token with Cloudflare and creates the SSL certificate. This can take up to a minute, so just let it run for a while. If succeeds, the window will disappear and you'll see a new SSL certificate in your list. If you have multiple and you're not sure which one is the new one, it'll be the one an expiry date 90 days away.

If it fails, look at the certificate entry to see the error. It's most likely to say "permission denied" which means you configured the API token incorrectly or the Cloudflare API timed out for some reason. The solution to both issues is to remove the token you just made and follow from [Step 4.1](#4-1) again.

### 4.3 Find the cert files inside NPM

To get a list of NPM IDs, run the command below (in PowerShell or WSL). This lists the IDs of all certificates generated by NPM on your machine, formatted as `npm-[number]`.

```bash
docker exec -it docker-npm-1 ls /etc/letsencrypt/live/
```

If this returns multiple NPM IDs, the one that corresponds to this project is probably the one with the highest number, but it's good to double check. To find which ID is correct:

```bash
docker exec -it docker-npm-1 sh -c "for d in /etc/letsencrypt/live/npm-*/; do echo \$d; openssl x509 -noout -subject -in \"\$d/cert.pem\" 2>/dev/null; done"
```

This pulls the `-subject` line from the certificate, which will show you which service you created it for: it will say `CN=yourdomain.com`.

> **Note**
> Most commands that begin with `docker` can be run from both file systems, NTFS or WSL. Going forward, know that if you see a command beginning in `docker exec`, you can run that line of code from whatever terminal you have open at that moment, and you'll get the same result. This is also true for many other `docker` commands, but not all: for example, `docker compose up` must be run in a directory with a `docker-compose` file (unless you specify a path in the command).

### 4.4 Copy cert files to Windows NGINX

The cert files in `/etc/letsencrypt/live/[npm-ID]/` are symlinks pointing to `/etc/letsencrypt/archive/[npm-ID]/`. `docker cp` chokes on relative symlinks, so copy the actual archive files. First resolve the symlinks, replacing [npm-ID] with the ID you found in the previous step:

```bash
docker exec docker-npm-1 readlink /etc/letsencrypt/live/[npm-ID]/fullchain.pem
docker exec docker-npm-1 readlink /etc/letsencrypt/live/[npm-ID]/privkey.pem
```

Then copy from PowerShell:

```bash
docker cp docker-npm-1:/etc/letsencrypt/archive/[npm-ID]/fullchain1.pem C:\Users\[user]\Documents\NGINX\conf\newsblur-fullchain.pem
docker cp docker-npm-1:/etc/letsencrypt/archive/[npm-ID]/privkey1.pem C:\Users\[user]\Documents\NGINX\conf\newsblur-privkey.pem
```

The numeric suffix in the archive filename (`1`, `2`, ...) increments with each renewal.

Verify the files are correct:

```powershell
Get-Content C:\Users\[user]\Documents\NGINX\conf\newsblur-fullchain.pem -TotalCount 1
# Expected: -----BEGIN CERTIFICATE-----

Get-Content C:\Users\[user]\Documents\NGINX\conf\newsblur-privkey.pem -TotalCount 1
# Expected: -----BEGIN PRIVATE KEY----- (or BEGIN RSA PRIVATE KEY / BEGIN EC PRIVATE KEY)
```

If either file returns the expected header of the other, double check the commands you used to copy the files from PowerShell. You can just run the correct commands again and they will overwrite the existing files with correct versions.

Also confirm the fullchain has two cert blocks (your leaf + LE intermediate):

```powershell
(Get-Content C:\Users\[user]\Documents\NGINX\conf\newsblur-fullchain.pem | Select-String "BEGIN CERTIFICATE").Count
# Expected: 2
```

If you see only 1, browsers won't trust the cert because the chain is incomplete. Double check your copy commands, and if they were correct, you may have to go back to [Step 4.2](#4-2) and generate a new certificate.

### 4.5 Reload NGINX

```powershell
cd C:\Users\[user]\Documents\NGINX
.\NGINX.exe -t          # test config
.\NGINX.exe -s reload   # apply
```

`NGINX -t` should print `syntax is ok` and `test is successful`. If it reports a cert load error, the file contents are wrong. Go back to [Step 4.2](#4-2) and verify the headers again, and if they look right, go back to 4.2 and generate a new cert.

### 4.6 Test

From another machine on your tailnet:

```bash
nslookup yourdomain.com          # should return your tailnet IP
curl -v https://yourdomain.com   # should connect, no cert warnings, return a 302 to /web/
```

Open `https://yourdomain.com` in a browser. You should land on NewsBlur with a trusted cert and no warnings.

## Step 5: Automated Certificate Renewal

Let's Encrypt certs are valid for 90 days; NPM auto-renews them at the 60-day mark inside the container, but those renewed files don't automatically propagate to your Windows NGINX. Without automation, you'll get a "certificate expired" warning in 90 days and have to figure this out all over again. Instead, spend a minutes automating the renewal and you'll never have to worry about it again.

### 5.1 Create the renewal script

Create a `scripts` folder in `C:\Users\[user]\Documents\NGINX\`, create a file called `renew-certs.ps1` in the folder, and copy the following to that file:

```powershell
# Cert renewal script for NewsBlur (and other domains as you add them)
# Copies LE certs from NPM container to Windows NGINX, then reloads NGINX.

$confPath = "C:\Users\[user]\Documents\NGINX\conf"
$NGINXPath = "C:\Users\[user]\Documents\NGINX"
$logPath = "C:\Users\[user]\Documents\NGINX\logs\cert-renewal.log"

New-Item -ItemType Directory -Force -Path (Split-Path $logPath) | Out-Null

function Copy-Cert {
    param($npmId, $domainPrefix)

    # Resolve the symlinks to find actual archive filenames
    $fullchainTarget = docker exec docker-npm-1 readlink /etc/letsencrypt/live/$npmId/fullchain.pem
    $privkeyTarget   = docker exec docker-npm-1 readlink /etc/letsencrypt/live/$npmId/privkey.pem

    # Strip the "../../archive/" prefix to get the archive filename
    $fullchainFile = $fullchainTarget -replace '\.\./\.\./archive/[^/]+/', ''
    $privkeyFile   = $privkeyTarget   -replace '\.\./\.\./archive/[^/]+/', ''

    docker cp "docker-npm-1:/etc/letsencrypt/archive/$npmId/$fullchainFile" "$confPath\$domainPrefix-fullchain.pem"
    docker cp "docker-npm-1:/etc/letsencrypt/archive/$npmId/$privkeyFile"   "$confPath\$domainPrefix-privkey.pem"

    # Verify it actually copied a key, not another cert
    $firstLine = Get-Content "$confPath\$domainPrefix-privkey.pem" -TotalCount 1
    if ($firstLine -notmatch "BEGIN .*PRIVATE KEY") {
        throw "$domainPrefix-privkey.pem doesn't look like a private key: first line is '$firstLine'"
    }
}

try {
    "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Starting cert renewal" | Out-File -Append $logPath

    Copy-Cert -npmId "[npm-ID]" -domainPrefix "news"

    & "$NGINXPath\NGINX.exe" -s reload -p $NGINXPath

    "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - Renewal completed successfully" | Out-File -Append $logPath
}
catch {
    "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') - ERROR: $_" | Out-File -Append $logPath
    exit 1
}
```

Replace `[user]` with your username. Add additional `Copy-Cert` lines if you add more services with a similar configuration later, replacing the NewsBlur NPM ID with theirs.

### 5.2 Allow PowerShell scripts to run

By default, Windows blocks unsigned `.ps1` scripts. Open PowerShell with your standard user permissions:

```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```

This allows local scripts to run freely while still blocking scripts downloaded from the internet so you aren't disabling the most important protections.

### 5.3 Test the script manually

```powershell
& "C:\Users\[user]\Documents\NGINX\scripts\renew-certs.ps1"
```

Check `C:\Users\[user]\Documents\NGINX\logs\cert-renewal.log`. The most recent entry should contain "completed successfully". The cert files in `conf\` should also have new modification timestamps (check last modified date and time with in file properties, or **Alt+Enter**).

If anything has thrown an error in this step but the site is still working, make sure you fix it now anyway, because if the certs renew incorrectly, you won't know for three months.

### 5.4 Schedule it

Open Task Scheduler. Click **Create Task** (and not "Create Basic Task").

**General tab:**

- Name: `NewsBlur Cert Renewal`
- Check "Run whether user is logged on or not"
- Check "Run with highest privileges"

**Triggers tab → New:**

- Begin: On a schedule
- Monthly, all months, day 1.
- Start time: 3:00 AM (anytime works, just avoid times you're actively using the server)

**Actions tab → New:**

- Action: Start a program
- Program/script: `powershell.exe`
- Add arguments: `-ExecutionPolicy Bypass -NoProfile -File "C:\Users\[user]\Documents\NGINX\scripts\renew-certs.ps1"`

**Conditions tab:**

- Uncheck "Start the task only if the computer is on AC power" if your server is a laptop that isn't plugged in 100% of the time.

**Settings tab:**

- Check "Run task as soon as possible after a scheduled start is missed"
- Check "If the task fails, restart every: 1 hour" (max 3 attempts)

Save (it'll prompt for your password to confirm).

Test by right-clicking the task in the library → **Run**. After 5 seconds, check "Last Run Result". If the code is `0x0`, it's working. Anything else means there was an error, and you should go through the settings for the task you just created to make sure everything is correct.

> **Note**
> Let's Encrypt certificates expire after 90 days and NPM renews them after 60, but I recommend running the renewal monthly because there's essentially no downside, and it means if the renewal ever fails and has to wait until the next month to try again, it'll still renew about 30 days before that cert would have actually expired.

## Step 6: Email Notifications

NewsBlur can send emails when feeds publish new stories, which was a major draw for me. There's a lot of customization for which emails (and other types of notifications) are sent, but in order to get there, you have to set some things up behind the scenes.

### 6.1 Configure SMTP

Email won't actually send until you provide SMTP credentials. I use Proton Mail, so this guide will be Proton-specific. Note that Proton requires a paid plan for both SMTP token generation (required) and connecting a custom domain (optional). Most major email providers have SMTP capability and there are comprehensive guides on all of them, but the overall process is pretty similar. I'm assuming you've already set up your domain's email to be managed by Proton, but if you haven't, check out [Proton's guide](https://proton.me/support/custom-domain) on setting it up. It's pretty simple, especially if you've made it this far, because that means you already know how to manage DNS records.

To generate an SMTP token:

1. Settings → All settings → IMAP/SMTP → SMTP tokens
2. Click Generate token and name it something like "NewsBlur"
3. Select your custom-domain address from the dropdown
4. Copy the generated token immediately (it's shown once)

Add to `newsblur_web/local_settings.py`:

```python
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp.protonmail.ch'
EMAIL_PORT = 587
EMAIL_HOST_USER = 'notifications@yourdomain.com'  # your Proton custom-domain address
EMAIL_HOST_PASSWORD = 'your-smtp-token-from-step-3'
EMAIL_USE_TLS = True
DEFAULT_FROM_EMAIL = 'NewsBlur <notifications@yourdomain.com>'
SERVER_EMAIL = 'notifications@yourdomain.com'
```

Restart the containers that handle email:

```bash
docker compose restart newsblur_web task_celery
```

### 6.2 Test SMTP

Django has a built-in test command:

```bash
docker exec -it newsblur_web ./manage.py sendtestemail your.real@email.address
```

You should use the email you want notifications sent to, likely a different one than `notifications@yourdomain.com` that you're using to send them. A test email should arrive within a minute. If it returns an error, the message should tell you exactly what's wrong (usually wrong credentials or wrong port).

### 6.3 Configure per-feed notifications

In the NewsBlur UI:

- Right-click any feed → **Notifications**. This will let you edit notifs on a per-feed basis.
- Or use the gear menu → **Manage** → **Notifications**. This will let you edit notifs from every feed at once.

Toggle "Email" on for each feed you want emailed. Choose "All Stories" for low-volume feeds where every story matters (status pages, infrastructure alerts). Choose "Focus Stories" for high-volume feeds where you only want notifications on stories matching your trained preferences.

## Troubleshooting

Helpful commands, and common issues and how to solve them

The most useful general advice is to trace the request layer by layer. Run `openssl s_client` from the server to see what cert is being served at each point in the stack, use `docker logs` to see what happened within each container, and use `netstat`/`ss` to verify what's actually listening on each port. Most issues become obvious when you figure out where in the chain they happened.

### Find what's listening on a port

In PowerShell:

```powershell
# Shows all PIDs listening on port 443
netstat -ano | findstr ":443"

# Then look up the PID
Get-Process -Id <PID> | Select-Object Id, ProcessName, Path
```

You can also find PIDs in **Task Manager** → **Details**.

In WSL:

```bash
# Shows all PIDs listening on port 443
sudo ss -tlnp | grep ':443'

# Or
sudo lsof -i :443
```

### See what cert is being served

From WSL on the server:

```bash
# For issuer, domain, and valid timespan
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com </dev/null 2>/dev/null | openssl x509 -noout -issuer -subject -dates

# For the above information, the full certificate, and more
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com -showcerts </dev/null 2>/dev/null | grep "BEGIN CERTIFICATE"
```

From PowerShell on any machine, without using openssl:

```powershell
$req = [System.Net.WebRequest]::Create("https://yourdomain.com")
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
$req.GetResponse() | Out-Null
$req.ServicePoint.Certificate | Format-List Issuer, Subject, NotAfter
```

You can also see most useful information from a browser by clicking the lock icon next to the URL and finding Certificate Details.

### Checking cert expiry

You can audit any cert from PowerShell:

```powershell
$cert = Get-PfxCertificate -FilePath C:\Users\[user]\Documents\NGINX\conf\newsblur-fullchain.pem
$cert.NotAfter
```

Or from the server with openssl:

```bash
openssl x509 -enddate -noout -in /path/to/fullchain.pem
```

If your renewal script is working ([Step 5](#step-5-automated-certificate-renewal)), you should never have to worry about this.

### Monitoring logs

You can find a lot of useful information by watching the logs for the tools you believe are points of failure. Each of these commands monitors logs in real time, so run the command and watch for changes.

In PowerShell:

```powershell
# NGINX logs
Get-Content C:\Users\[user]\Documents\NGINX\logs\error.log -Wait -Tail 0
```

In WSL:

```bash
# NewsBlur web container logs
docker logs newsblur_web --tail 100 -f

# HAProxy logs
docker logs newsblur_haproxy --tail 100 -f
```

### 502 Bad Gateway errors

I ran into this a few times. If this happens, check NGINX logs.

| Error in log | Cause | Fix |
|--------------|-------|-----|
| `SSL_do_handshake() failed ... wrong version number` | You're probably sending HTTPS traffic to an HTTP-only upstream | In `nginx.conf`, change `proxy_pass` URL from `https://` to `http://`. For NewsBlur, this should only be temporary for testing |
| `peer closed connection in SSL handshake` | You're probably sending HTTP traffic to an HTTPS-only upstream | In `nginx.conf`, change `proxy_pass` URL from `http://` to `https://`. For NewsBlur, this should be what the final config contains |
| `connect() failed (10061: ...)` | Something upstream isn't listening on the port | Verify each upstream service is running and sending to the expected port |
| `upstream timed out` | Something upstream is slow or stuck | Restart upstream containers. If that doesn't work, look at other logs for erros. If nothing else works, try increasing timeouts for upstream containers |
| `no live upstreams` | At least one upstream server is down | Start/restart upstream containers and look at logs if they're failing |

### Redirects

If your page redirects to example.com, the issue is that Django Sites framework defaults to example.com, its placeholder. To fix it, update `Site.objects.get(pk=1)` in Django shell (see [Step 2.3](#2-3))

If your page is stuck in a redirect loop, which is a common issue when setting up NewsBlur, the page won't load and you'll get an error message. Those messages look like this:

| Browser | Message |
|---------|---------|
| Chrome, DuckDuckGo, other Chromium browsers | "ERR_TOO_MANY_REDIRECTS" |
| Firefox | "The page isn't redirecting properly." |
| Microsoft Edge | "This page isn't working right now." |
| Safari | "Safari Can't Open the Page." |

Here are common issues and their fixes:

| Cause | Fix |
|-------|-----|
| NewsBlur may be trying to parse the subdomain as a username | Add subdomain to `ALLOWED_SUBDOMAINS` in `apps/reader/views.py` ([Step 2.4](#2.4)) |
| `SECURE_SSL_REDIRECT` without `SECURE_PROXY_SSL_HEADER` | Add `SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')` to local_settings.py |
| `SESSION_COOKIE_DOMAIN` mismatch | Set in both local_settings.py AND ensure `NEWSBLUR_URL` env var is set so conditional in docker_local_settings.py doesn't fire |

### HSTS warning

If you get an error page that looks similar to the untrusted certificate page you might get on sites serving HTTP or without a valid SSL certificate, but you don't have the option to bypass the warning, it's almost definitely an HSTS issue (HSTS is essentially an HTTPS requirement). Having HSTS enabled is a good thing, but you must have a valid SSL or the site will be completely inaccessible. The error is likely happening for one of two reasons: either your `conf/nginx.conf` file is linking to the wrong SSL, or your browser has an old one cached. Check your certificate paths and update the paths, and clear your browser's HSTS cache for your domain by doing the following:

**Chrome/Edge:**

- Navigate to `chrome://net-internals/#hsts`
- Scroll to "Delete domain security policies"
- Type your domain → Delete
- Restart browser

**Firefox:**

- Close Firefox
- Firefox's Profile folder → edit `SiteSecurityServiceState.txt`
- Remove the line for your domain
- Reopen

The lines you just deleted will be repopulated with the updated information the next time you visit the site.

### Elasticsearch container is in a restart loop

Check the container log:

```bash
docker logs newsblur_db_elasticsearch
```

| Error | Cause | Fix |
|-------|-------|-----|
| `Could not create the Java Virtual Machine` + `Unrecognized VM option 'UseSVE=0'` | x86_64 system trying to use ARM flag | Remove `-XX:UseSVE=0` from `ES_JAVA_OPTS` in docker-compose.yml (see [Step 1.5](#1-5)) |
| OOM killed (process died, large memory before exit) | Insufficient memory in WSL2 VM | Increase memory in `.wslconfig`, run `wsl --shutdown`, restart (see [Step 1.6](#1-6)) |
| `max virtual memory areas vm.max_map_count [...] is too low` | Linux kernel parameter too low | `sudo sysctl -w vm.max_map_count=262144` (see [Step 1.7](#1-7)) |
| `java.nio.file.AccessDeniedException` | Permissions issue, likely with `node.lock` | See below |

### Permissions issues causing restart loops

If one or more services are constantly restarting, there may be a permissions issue. This is common in setups where WSL2 runs as `root`, or where files inside the NewsBlur directory were created by a different user than the one Docker containers run as. To fix it, you need to align ownership across the host filesystem and the container processes.

If this is an issue for one container, it's likely to be an issue for others. Check them all so you can update permissions for everything at once. In the `/NewsBlur` directory in WSL:

```bash
# Stop all containers first
docker compose stop

# Find the UIDs and GIDs of each volume
docker compose run --rm --entrypoint id newsblur_db_postgres
docker compose run --rm --entrypoint id newsblur_db_mongo
docker compose run --rm --entrypoint id newsblur_db_elasticsearch
docker compose run --rm --entrypoint id newsblur_web
docker compose run --rm --entrypoint id newsblur_node
docker compose run --rm --entrypoint id task_celery
```

Each command will return a line like `uid=1000 gid=1000 groups=1000`. You can ignore `groups`.

If there are mismatches between any UIDs and GIDs, the command below will apply ownership of each volume to match its container's ID. Match both numbers in each line to the UID returned above- the numbers below are likely to be correct but they are placeholders:

```bash
sudo chown -R 999:999 docker/volumes/postgres
sudo chown -R 999:999 docker/volumes/db_mongo
sudo chown -R 1000:1000 docker/volumes/elasticsearch
sudo chown -R 1000:1000 logs .prom_cache  # used by newsblur_web, task_celery, newsblur_node

# Restart everything
docker compose up -d
```

> **Note 1**
> You may have to run this again after [updating NewsBlur](#updating-newsblur).

> **Note 2**
> You may notice that `newsblur_db_redis`, the fourth directory in `docker/volumes`, isn't addressed in the above commands. This is because Redis' imagefile doesn't define a user, meaning it's `root` by default and you won't run into any read/write/execute issues.

> **Note 3**
> These permissions issues may not stop NewsBlur's frontend from running, but it's still a problem. In my case, `newsblur_celery` and `newsblur_db_elasticsearch` were stuck in loops without affecting the website, but the former meant Celery couldn't access Django's logging system which would have caused diagnosis issues down the line.

### MongoDB fails to start with WiredTiger

MongoDB didn't shut down properly. In WSL:

```bash
make mongo-repair
```

If that doesn't work, delete and rebuild the data directory (only do this on a fresh install with no data you care about):

```bash
docker compose down
rm -rf docker/volumes/db_mongo
make
```

### Site times out from a remote computer but works from the server

Several possible causes:

| Cause | Fix |
|-------|-----|
| DNS hasn't propagated | Wait a few minutes after adding the Cloudflare record |
| Tailscale isn't connected on at least one device | Verify on both devices using the Tailscale tray app or with `tailscale status` in PowerShell |
| A native Windows service is already listening on port 443 | Check `netstat -ano | findstr ":443"` and end the unwanted process. This is often IIS or an old NGINX installation |

## Backups and Updates

### Backing up

Backing up your database occasionally, especially right before updating NewsBlur or NPM, is a good practice.

Your critical data is in WSL, in these directories:

- `~/NewsBlur/docker/volumes/postgres/` (subscriptions, user accounts, intelligence training)
- `~/NewsBlur/docker/volumes/db_mongo/` (stories, read states)
- `~/NewsBlur/newsblur_web/local_settings.py` (your custom configuration)
- `~/npm/data/` and `~/npm/letsencrypt/` (NPM's config and issued certs)

In order to back up these directories, you can run a script. Here's a simple version with comments explaining what it does:

```bash
# Defines BACKUP_DIR as the folder /backups/[date]. Creates /backups folder or uses existing one
BACKUP_DIR=~/backups/$(date +%Y-%m-%d)
mkdir -p "$BACKUP_DIR"

# Stops Postgres and Mongo temporarily (optional but good practice)
cd ~/NewsBlur && docker compose stop newsblur_db_postgres newsblur_db_mongo

# Copies all important directories to BACKUP_DIR
tar czf "$BACKUP_DIR/newsblur-volumes.tar.gz" -C ~/NewsBlur docker/volumes
cp ~/NewsBlur/newsblur_web/local_settings.py "$BACKUP_DIR/"
tar czf "$BACKUP_DIR/npm-data.tar.gz" -C ~/npm data letsencrypt

# Restarts Postgres and Mongo
cd ~/NewsBlur && docker compose start newsblur_db_postgres newsblur_db_mongo

# Optional: copies new backup folder to an external location (replace path with your own)
rsync -avz "$BACKUP_DIR" /mnt/external-drive/NewsBlur/backups
```

I recommend scheduling this via cron or systemd weekly so you don't have to do it manually.

### Updating NewsBlur

NewsBlur updates are pretty rare, but they do happen and can include important bug fixes. Back up your important directories beforehand. To update:

```bash
cd ~/NewsBlur
git pull
make
```

`make` re-runs migrations as part of its install process. The update typically takes 1-3 minutes.

**Important**: `git pull` will overwrite your edit to `apps/reader/views.py` (the `ALLOWED_SUBDOMAINS` change from [Step 2.4](#2-4)). This will break your connection if you set up access via a subdomain. Either:

1. Maintain a personal git branch with your modifications, and rebase on top of upstream after each pull
2. Document the change in a runbook so you can re-apply manually
3. Use `git stash` before pulling, then `git stash pop` after

Updating may also change permissions for some directories, (often Celery's access to the Django logs) starting restart loops. If this happens, follow the instructions in [Permission issues causing restart loops](#permissions-issues-causing-restart-loops).

### Updating NPM

```bash
cd ~/npm
docker compose pull
docker compose up -d
```

NPM releases updates pretty often. Read release notes for breaking changes before updating, but for this project, updates to NPM are very unlikely to cause issues. Also, you don't have to run the post-updates step in the previous command after updating NPM, just NewsBlur itself.

## Substitutions

### Different DNS provider

I used Cloudflare DNS because their API is among the best-supported in NPM and many other tools. Most providers have a similar process to Cloudflare, but often have slight differences in their name or process. Here's a list of common substitutions:

| Provider | Comparison to Cloudflare |
|----------|--------------------------|
| AWS Route 53 | Provide AWS access key + secret in credentials |
| DigitalOcean | Provide DO API token |
| Hetzner | Provide Hetzner API token |
| Linode | Provide Linode API token |
| Namecheap | Provide username + API key |
| OVH | Application key + secret + consumer key |
| Gandi | Provide Personal Access Token |
| deSEC | Provide token |
| GoDaddy | Provide API key + secret |
| Vultr | Provide API key |
| Google Cloud DNS | Service account JSON |

The process in NPM is identical regardless of provider, and all of the providers listed above are listed in the "DNS Provider" dropdown. Only the credentials file format change, and it should be clear what you need to enter.

For DNS providers without a plugin (some registrars don't expose an API), you can't use a DNS-01 challenge through NPM. Alternatives:

- Switch DNS to a supported provider (free Cloudflare DNS works regardless of where the domain is registered and switching is easy)
- Use `acme.sh` manually with whatever method your registrar supports (some have custom plugins for `acme.sh` even when NPM doesn't
- If you're exposing your NewsBlur instance to the open internet (skipping the Tailscale private network step), you can use HTTP-01 instead

### Different VPN

This guide uses Tailscale because it has the friendliest setup and works on every platform. Substitutions:

- **WireGuard (vanilla)**: more work to set up but identical from NewsBlur's perspective. Your server gets a private IP in the wg range (e.g., 10.0.0.x), you point DNS at it, and traffic flows the same way. The Docker Desktop limitation about not binding to VPN interfaces still applies, so the "native NGINX as the entry point" design is still needed.
- **Headscale**: open-source Tailscale-compatible coordination server. If you don't want to depend on Tailscale's hosted service, run your own Headscale instance. Client config and routing behavior are otherwise identical to Tailscale.
- **ZeroTier**: similar mesh-VPN concept, also assigns private IPs. Same overall architecture, same Docker-on-Windows limitation.
- **OpenVPN / IPSec / commercial VPNs**: workable but heavier. doable but not the best tool for the job. Most commercial VPNs don't give you a stable IP-to-server mapping that would work with this configuration, so mesh VPNs (Tailscale, Headscale, ZeroTier) are the right tool.

### No VPN

If you don't want a VPN and just want your NewsBlur to be reachable on your custom domain, there are a few changes to make and steps to skip.

**Please note** that having an authentication layer in front of all home server projects is strongly advised, because even though most services like NewsBlur have their own logins that protect the information inside that service, public exposure to your personal machine increases your risk significantly (especially with Windows PCs used as servers). Your home server will just not have the same level of security as most publically-facing services, so be very careful about this decision.

If you know what you're doing, here's what to do differently in this guide:

1. Set the Cloudflare A record to your public IP (or use a Dynamic DNS service like ddclient or Cloudflare-DDNS if your IP is dynamic)
2. You can use the proxied DNS now (orange cloud), since public IPs are reachable by Cloudflare
3. Forward ports 80 and 443 on your router to your server's LAN IP
4. Use HTTP-01 challenge in NPM (simpler than DNS-01)

### Different reverse proxy

Windows-native NGINX is what I'm most familiar with, but there are a few great alternatives:

- **Caddy**: nicer config syntax than NGINX and built-in ACME (meaning you can manage reverse proxies and SSL in one place and skip NPM entirely). Caddy can do DNS-01 directly with Cloudflare if you build it with the right plugin. Tradeoff: Caddy on Windows is less common, so there's less documentation than its Linux counterpart.
- **Traefik**: most popular in Docker-native setups, but also has a Windows binary version that you'd want to use in place of Windows NGINX to avoid the same VPN interface issue as NPM.
- **HAProxy**: this is a pretty heavy-duty option. It's capable of everything you need but the config language has a learning curve, and the Windows builds aren't amazing.
- **Linux server**: if you're able to move away from Windows for your server, you should. It simplifies pretty much all server projects significantly. NPM in Docker would be the public entry point because it doesn't have the VPN-interface limitation on Linux as it does on Windows, so you can skip the separate NGINX installation entirely. You'd also avoid using WSL because you'd just be running Linux natively instead of on top of Windows, meaning you wouldn't have to jump back and forth because different terminals and file systems.

### Different email provider

| Provider | Setup difficulty | Cost, message limits | Notes |
|----------|------------------|----------------------|-------|
| Gmail (App Password) | Easiest | Free, 500 messages/day | Set up 2FA, generate app password at `myaccount.google.com/apppasswords`, use `smtp.gmail.com:587`. |
| Outlook.com (App Password) | Easy | Free, 300/day | Setup similar to Gmail. Use `smtp.office365.com:587`. |
| Mailgun | Medium | Free tier, 100/day | Requires DNS records (SPF/DKIM) for sender domain like Proton. |
| SendGrid | Medium | Free tier, 100/day | Setup similar to Mailgun. |
| Brevo (Sendinblue) | Medium | Free tier, 300/day | Setup similar to Mailgun. Marketing focused, more bloat than the previous two if you just want the email delivery. |
| Postmark | Medium | Paid (free trial only) | Setup similar to Mailgun. |
| Amazon SES | Hard | Pay-as-you-go (~$0.10 / 1000) | Cheapest at high volumes but complex setup. Sandboxed by default, not recommended for casual personal use. |
| Self-hosted Postfix/Postfix-relay | Hard | Free | I have to mention a self-hosted option, but delivery to Gmail and Outlook addresses usually fail from home IPs even with proper SPF/DKIM/DMARC, so it's very hard to recommend. |

The Django config is the same for all of them, just customize `EMAIL_HOST`, `EMAIL_PORT`, and the credentials to your provider.

### NewsBlur alternatives

As ironic as it is to list alternatives to the central tool in the guide, there are some great ones. The networking and certificate parts of this guide apply the same way to these tools, all of which are FOSS like NewsBlur:

| Reader | Notes |
|--------|-------|
| FreshRSS | Lighter than NewsBlur with a simpler setup, but with fewer features. Runs in a single Docker container. |
| selfoss | Has a good plugin ecosystem, and written in PHP. Appealing to people like myself who are most familiar with web development. |
| Tiny Tiny RSS (tt-rss) | Older but still maintained, with active forums for support. |
| Miniflux | Minimalist and very fast, and runs as a single binary or Docker container. Written in Go. |
| CommaFeed | Supports Google Reader's API, and written in Java. |
| Stringer | I don't think it's very actively maintained but it's a good Ruby on Rails-based alternative. |

If NewsBlur's setup is too complicated for your taste, Miniflux or FreshRSS are your best bets. If you want easy client side customization, selfoss has custom CSS and JS capability built in.

## Conclusion

I hope this guide has been helpful. Please [reach out](/about/) if you have questions or something to add/fix - I'd like to make this as comprehensive as possible. I'd also just love to hear if you were successful in installing it. Happy homelabbing!

Written and tested by Emrys Mayell. [emrysmayell.com](https://emrysmayell.com/homelab/newsblur-tutorial/)